Effective Java 2.0_中文版_Item 8

文章作者:Tyan
博客:noahsnail.com | CSDN | 简书

CHAPTER3 所有对象的共通方法

虽然Object是一个具体的类,但设计它的主要目的是为了扩展。它的所有非final方法(equalshashCodetoStringclonefinalize)都有明确的通用约定,因为设计它们的目的是为了重写。任何类都应该遵循通用约定重写这些方法;不这样做的话,依赖这些约定的其它类(例如HashMapHashSet)将无法结合这个类正确运行。

会告本章诉你什么时候,怎样重写这些非final的Object方法。本章会忽略finalize方法,因为它在Item 7中已经讨论过了。虽然不是一个Object方法,但是这章仍会讨论Comparable.compareTo,因为它有一个类似的特性。

Item 8:当重写equals时要遵循通用约定

重写equals方法看似简单,但许多方式都会导致错误,结果是非常可怕的。避免这些问题的最简单方式是不要重写equals方法,在这种情况下类的每个实例只等价于它本身。如果符合以下任何条件,这样做就是正确的:

  • 类的每个实例本质上都是唯一的。对于表示活动实体而不是表示值的类确实如此,例如Thread。对于这些类,Object提供的equals实现具有完全正确的行为。

  • 不关心类是否提供“逻辑等价”的测试。例如,java.util.Random可以重写equals方法来检查两个Random实例是否会产生相同的随机数序列,但设计者认为客户不需要或者不想要这个功能。在这种情况下,从Object继承的equals实现就足够了。

  • 超类已经重写了equals,超类的行为对于子类是合适的。例如,大多数Set实现从AbstractSet继承了equals实现,List实现从AbstractList继承了equals实现,Map实现从AbstractMap继承了equals实现。

  • 类是私有的或包私有的,可以确定它的equals方法从不会被调用。可以说,在这些情况下equals方法应该重写,以防它被偶然调用:

1
2
3
@Override public boolean equals(Object o) {
throw new AssertionError(); // Method is never called
}

什么时候重写Object.equals方法是合适的?如果类具有逻辑等的概念,不同于对象同一性,并且超类没有重写equals方法来实现要求的行为,这时候就需要重写equals方法。这种情况通常是对值类而言的。值类仅仅是表示值的类,例如IntegerDate。程序员用equals方法比较值对象的引用,期望找出它们是否是逻辑等价的,而不管它们是否是同一对象。重写equals方法不仅满足了程序员的期望;它也能使实例作为映射表的主键或者集合的元素,使它们表现出可预期的行为。

有一种不需要重写equals方法的值类,它通过实例控制(Item 1)来确保每个值至多存在一个对象。枚举类型(Item 30)就是这种类。对于这种类而言,逻辑等价等同与对象同一性,Objectequals方法在功能上就如同逻辑等价方法。

当你重写equals方法时,你必须遵循通用约定。下面是约定内容,从Object规范[JavaSE6]中拷贝的:

equals实现了一种等价关系。它是:

  • 自反性:对于任何非空引用值xx.equals(x)必须返回true

  • 对称性:对于任何非空引用值xyx.equals(y)必须返回true当且仅当y.equals(x)返回true

  • 传递性:对于任何非空引用值,xyz,如果x.equals(y)返回true并且y.equals(z)返回true,则x.equals(z)必须返回true

  • 一致性:对于任何非空引用值xyx.equals(y)的多次调用一致返回true或一致返回false,假设对象进行equals比较时没有修改任何信息。

  • 对于非空引用值xx.equals(null)必须返回false

除非你擅长数学,否则这可能看起来有点可怕,但不要忽视它!如果你违反了它,你可能会发现你的程序表现不正常或程序崩溃,并且很难确定失败的来源。用John Donne的话来说,没有类是孤立的。一个类的实例频繁传递给另一个类。许多类,包括所有的集合类,都依赖于传递给它们的对象遵循equals约定。

现在你已经意识到了违反了equals约定的危险,让我们详细回顾一下这个约定。好消息是实际上这个约定并不复杂,尽管从表面上来看不是这样。一旦你理解了它,遵循它并不难。让我们依次检查这五个要求:

自反性——第一个要求仅仅是说一个对象必须等价于它本身。很难想象会无意的违反这个要求。如果你违反了它并将你的类实例添加到一个集合中,集合的contains方法可能会说这个集合中不包含你刚刚添加的实例。

对称性——第二个要求是说任何两个对象必须对它们是否相等达成一致。不像第一个要求,不难想象会无意的违反这个要求。例如,考虑下面的类,它实现了大小写敏感的字符串。字符串保存在toString中,但在比较时被忽略了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Broken - violates symmetry!
public final class CaseInsensitiveString {
private final String s;
public CaseInsensitiveString(String s) {
if (s == null)
throw new NullPointerException();
this.s = s;
}

// Broken - violates symmetry!
@Override public boolean equals(Object o) {
if (o instanceof CaseInsensitiveString)
return s.equalsIgnoreCase(((CaseInsensitiveString) o).s);
if (o instanceof String) // One-way interoperability!
return s.equalsIgnoreCase((String) o);
return false;
}
... // Remainder omitted
}

这个类中,equals方法的意图很好,单纯的想要与普通的字符串进行互操作。假设我们有一个区分大小写的字符串和一个普通的字符串:

1
2
CaseInsensitiveString cis = new CaseInsensitiveString("Polish");
String s = "polish";

正如预料的那样,cis.equals(s)返回true。问题是虽然CaseInsensitiveString中的equals知道普通的字符串,但是String中的equals方法不注意不区分大小写的字符串。因此s.equals(cis)返回false,这明显违反了对称性。假设你将一个不区分大小写的字符串放到一个集合中:

1
2
List<CaseInsensitiveString> list = new ArrayList<CaseInsensitiveString>();
list.add(cis);

这时list.contains(s)会返回什么?谁知道呢?在Sun当前的实现中,它碰巧会返回false,但那仅是一种实现方案。在另一种实现中,它也可能很容易的返回true或抛出一个运行时异常。一旦你违反了equals约定,当面对你的对象时,你根本不指定其它的对象行为会怎样。

为了消除这个问题,只要从equals方法中移除与String进行交互的,考虑不周的尝试即可。一旦你这样做了,你可以重构这个方法给它一个返回即可:

1
2
3
4
 @Override 
public boolean equals(Object o) {
return o instanceof CaseInsensitiveString && ((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
}

传递性——equals约定的第三个要求是说如果一个对象等价于第二个对象,而第二个对象等价于第三个对象,则第一个对象等价于第三个对象。同样的,不难想象会无意中违反这个要求。考虑这样一种情况,子类添加一个新的值组件到它的超类中。换句话说,子类添加的信息会影响equals比较。以一个简单的不可变的二维整数点类作为开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}

@Override
public boolean equals(Object o) {
if (!(o instanceof Point))
return false;
Point p = (Point)o;
return p.x == x && p.y == y;
}
... // Remainder omitted
}

假设你想扩展这个类,给点添加颜色的概念:

1
2
3
4
5
6
7
8
public class ColorPoint extends Point {
private final Color color;
public ColorPoint(int x, int y, Color color) {
super(x, y);
this.color = color;
}
... // Remainder omitted
}

equals方法应该看起来是怎样的?如果一点也不修改,直接从Point继承equals方法,在进行equals比较时颜色信息会被忽略。虽然这没有违反equals约定,但很明显这是不可接受的。假设你写了一个equals方法,只有在它的参数是另一个有色点,且它们具有相同的位置和颜色时才返回true

1
2
3
4
5
6
7
// Broken - violates symmetry!
@Override
public boolean equals(Object o) {
if (!(o instanceof ColorPoint))
return false;
return super.equals(o) && ((ColorPoint) o).color == color;
}

这个方法的问题在于:当你比较一个普通点和一个有色点或相反的情况时,你可能会得到不同的结果。前者的比较忽略了颜色,而后者总是返回false,因为参数类型不正确。为了使这个更具体一点,我们创建一个普通点和一个有色点:

1
2
Point p = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, Color.RED);

p.equals(cp)返回true,而cp.equals(p)返回false。你可能想让ColorPoint.equals进行比较混合比较时忽略颜色来修正这个问题:

1
2
3
4
5
6
7
8
9
10
11
// Broken - violates transitivity!
@Override
public boolean equals(Object o) {
if (!(o instanceof Point))
return false;
// If o is a normal Point, do a color-blind comparison
if (!(o instanceof ColorPoint))
return o.equals(this);
// o is a ColorPoint; do a full comparison
return super.equals(o) && ((ColorPoint)o).color == color;
}

这个方法提供了对称性,但违反了传递性:

1
2
3
ColorPoint p1 = new ColorPoint(1, 2, Color.RED);
Point p2 = new Point(1, 2);
ColorPoint p3 = new ColorPoint(1, 2, Color.BLUE);

现在p1.equals(p2)p2.equals(p3)返回true,而p1.equals(p3)返回false,很明显这违反了传递性。前两个比较忽略了颜色,而第三个比较考虑了颜色。

因此解决方案是什么?事实证明:在面向对象语言中,等价关系问题是一个基本的问题。无法在扩展一个实例化的类并添加值组件的同时,还保留equals约定,除非你愿意放弃面向对象抽象的优势。

你可能听说过你可以在equals方法中通过使用getClass测试代替instanceof测试,从而在扩展一个可实例化的类并添加值组件的同时,保留equals约定:

1
2
3
4
5
6
7
8
// Broken - violates Liskov substitution principle (page 40)
@Override
public boolean equals(Object o) {
if (o == null || o.getClass() != getClass())
return false;
Point p = (Point) o;
return p.x == x && p.y == y;
}

当且仅当它们具有相同的实现类时,上面的代码在比较对象时才会有效。虽然这不是很糟糕,但结果是不可接受的。

假设我们想写一个方法来判断一个整数点是否在单位圆上。下面是一种写法:

1
2
3
4
5
6
7
8
9
10
11
// Initialize UnitCircle to contain all Points on the unit circle private static final Set<Point> unitCircle;
static {
unitCircle = new HashSet<Point>();
unitCircle.add(new Point( 1, 0));
unitCircle.add(new Point( 0, 1));
unitCircle.add(new Point(-1, 0));
unitCircle.add(new Point( 0, -1));
}
public static boolean onUnitCircle(Point p) {
return unitCircle.contains(p);
}

虽然这可能不是实现这个功能的最快方式,但它确实有效。但假设你以某种不添加值组件的方式扩展了Point,例如通过它的构造函数来追踪创建了多少实例:

1
2
3
4
5
6
7
8
9
10
11
12
public class CounterPoint extends Point {
private static final AtomicInteger counter = new AtomicInteger();

public CounterPoint(int x, int y) {
super(x, y);
counter.incrementAndGet();
}

public int numberCreated() {
return counter.get();
}
}

里氏替换原则认为,一个类型的任何重要属性也适用于它的子类型,因此该类型编写的任何方法在它的子类型中也都应该工作良好[Liskov87]。但假设我们给onUnitCircle传递了一个CounterPoint实例。如果Point类使用了基于getClassequals方法,onUnitCircle将会返回false,无论CounterPoint实例的x值和y值是多少。这是因为集合,例如onUnitCircle方法中的HashSet,使用equals方法来测试是否包含元素,没有CounterPoint实例等于Point。然而,如果你在Point上使用合适的基于instanceofequals方法,当面对CounterPoint时,同样的onUnitCircle方法会工作的很好。

尽管没有令人满意的方式来扩展一个可实例化的类并添加值组件,但有一个很好的解决方案。遵循Item 16 “Favor composition over inheritance”的建议,不再让ColorPoint继承Point,而是通过在ColorPoint中添加一个私有的Point字段和一个公有的视图方法(Item 5),此方法返回一个与有色点具有相同位置的普通点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Adds a value component without violating the equals contract
public class ColorPoint {
private final Point point;
private final Color color;
public ColorPoint(int x, int y, Color color) {
if (color == null)
throw new NullPointerException();
point = new Point(x, y);
this.color = color;
}

/**
* Returns the point-view of this color point.
*/
public Point asPoint() {
return point;
}
@Override
public boolean equals(Object o) {
if (!(o instanceof ColorPoint))
return false;
ColorPoint cp = (ColorPoint) o;
return cp.point.equals(point) && cp.color.equals(color);
}
... // Remainder omitted
}

在Java平台库中有一些类扩展了一个可实例化的类并添加了一个值组件。例如,java.sql.Timestamp扩展了java.util.Date并添加了一个nanoseconds字段。Timestampequals实现确实违反了对称性,如果TimestampDate用在同一个集合中或混杂在一起,会引起不稳定的行为。Timestamp类有一个免责声明,警告程序员不要混合日期和时间戳。虽然只要你将它们分开就不会有麻烦,但是没有任何东西阻止你混合它们,而且产生的错误很难调试。Timestamp类的这个行为是一个错误,不应该进行模仿。

注意,你可以添加值组件到抽象类的子类而且不会违反equals约定。对于遵循Item 20 “Prefer class hierarchies to tagged classes”的建议而得到这种类层次来说,这是非常重要的。例如,你可以有一个没有值组件的抽象类Shape,子类Circle添加了radius字段,子类Rectangle添加了lengthwidth字段。只要不能直接创建一个超类实例,上面的种种问题就不会发生。

一致性——equals约定的第四个要求是说如果两个对象相等,它们必须一致相等,除非其中一个(或二者)被修改了。换句话说,可变对象在不同的时间可以等于不同的对象而不可变对象不能。当你写了一个类,仔细想想它是否应该是不可变的(Item 15)。如果你推断它应该是不可变的,那么要确保你的equals方法满足这样的约束条件:相等的对象永远相等,不等的对象永远不等。

无论一个类是否是不可变的,都不要写一个依赖于不可靠资源的equals方法。如果你违反了这个禁令,要满足一致性要求是非常困难的。例如,java.net.URLequals方法依赖于对关联URL主机的IP地址的比较。将主机名转换成IP地址可能需要访问网络,随时间推移它不能保证取得相同的结果。这可能会导致URL equals方法违反equals约定并在实践中产生问题。(很遗憾,由于兼容性问题,这一行为不能被修改。)除了极少数例外,equals方法应该对常驻内存对象进行确定性计算。

非空性”——最后的要求由于没有名字我称之为“非空性”,这个要求是说所有的对象都不等于null。虽然很难想象调用o.equals(null)会偶然的返回true,但不难想象会意外抛出NullPointerException的情况。通用约定不允许出现这种情况。许多类的equals方法为了防止出现这种情况都进行对null的显式测试:

1
2
3
4
5
6
@Override 
public boolean equals(Object o) {
if (o == null)
return false;
...
}

这个测试是没必要的。为了平等测试其参数,为了调用它的访问器或访问其字段,equals方法首先必须将它的参数转换成合适的类型。在进行转换之前,equals方法必须使用instanceof操作符来检查它的参数是否是正确的类型:

1
2
3
4
5
6
7
@Override 
public boolean equals(Object o) {
if (!(o instanceof MyType))
return false;
MyType mt = (MyType) o;
...
}

如果缺少类型检查,equals方法传入了一个错误类型的参数,equals方法会抛出ClassCastException,这违反了equals约定。但当指定instanceof时,如果它的第一个操作数为null,无论它的第二个操作数是什么类型,它都会返回false[JLS, 15.20.2]。所以如果传入null类型检查将会返回false,因此你不必进行单独的null检查。

将这些要求结合在一起,得出了下面的编写高质量equals方法的流程:

  1. 使用==操作符来检查参数是否是这个对象的一个引用,。如果是,返回true。这只是一个性能优化,如果比较的代价有可能很昂贵,这样做是值得的。

  2. 使用instanceof操作符来检查参数类型是否正确。如果不正确,返回false。通常,正确的类型是指equals方法所在的那个类。有时候,它是这个类实现的一些接口。如果一个类实现了一个接口,这个接口提炼了equals约定来允许比较那些实现了这个接口类,那么就使用接口。集合接口例如SetListMapMap.Entry都有这个属性。

  3. 将参数转换成正确的类型。由于转换测试已经被instanceof在之前做了,因此它保证能成功。

  4. 对于类中的每一个“有效”字段,检查参数的这个字段是否匹配这个对象的对应字段。如果所有的这些测试都成功了,返回true;否则返回false。如果第二步中的类型是一个接口,你必须通过接口方法访问参数的字段;如果类型是一个类,你可能要直接访问字段,依赖于它们的可访问性。

对于基本类型,如果不是floatdouble,使用==操作符进行比较;对于对象引用字段,递归地调用equals方法;对于float自动,使用Float.compare方法;对于double字段,使用Double.comparefloatdouble字段的特别对待是有必要的,因为存在Float.NaN-0.0f和类似的double常量;更多细节请看Float.equals。对于数组字段,对每个元素应用这些指导。如果数组中的每个元素都是有意义的,你可以使用1.5版本中添加的Arrays.equals方法。

某些对象引用字段可能合理的包含null。为了避免产生NullPointerException的可能性,使用下面的习惯用法来比较这些字段:

1
(field == null ? o.field == null : field.equals(o.field))

如果fieldo.field经常是等价的,使用下面的可替代方式可能会更快:

1
(field == o.field || (field != null && field.equals(o.field)))

对于某些类而言,例如上面的CaseInsensitiveString,字段比较比简单的相等性检测更复杂。如果是这种情况,你可能想存储这个字段的标准形式,因此equals方法可以在这些标准形式上进行低开销的精确比较,而不是更高代码的非精确比较。这种技术最适合不可变类(Item 15);如果对象可以改变,你必须保持最新的标准形式。

equals方法的性能可能会受到字段比较顺序的影响。为了最佳性能,你首先应该比较那些更可能不同,比较代价更小的字段,或者理想情况下二者兼具的字段。你不能比较那些不属于对象逻辑状态一部分的字段,例如同步操作中的Lock字段。你也不需要比较冗余的字段,它们能从“有意义字段”中计算出来,但这样做可能会改善equals方法的性能。如果冗余字段相当于整个对象的概要描述,比较这个字段,如果失败的话会节省你比较真正数据的开销。例如,假设你有一个Polygon类,并且你缓存这个区域。如果两个多边形有不同的面积,你就不需要比较它们的边和顶点。

  1. 当你完成了equals方法的编写时,问你自己三个问题:它是否是对称的?是否是可传递的?是否是一致的?并且不要只问你自己;编写单元测试来检查是否拥有这些属性!如果没有这些属性,弄清楚为什么没有,对应的修改equals方法。当然你的equals方法也必须满足其它两个属性(自反性和“非空性”),但这两个属性通常会自动满足。

根据上述规则构建的equals方法具体例子请看Item 9的PhoneNumber.equals`。下面是一些最后的警告:

  • 当你重写equals时,总是重写hashCode方法(Item9)

  • 不要将equals声明中的Object对象替换为其它对象。对于程序员来讲,写一个equals方法看起来像下面的一样是不常见的,并且花费了好几个小时都不明白它为什么不能正确工作:

1
2
3
public boolean equals(MyClass o) {
...
}

正如本条目阐述的那样,@Override注解的一致使用会阻止你犯这个错误(Item 36)。这个equals方法不能编译并且错误信息会确切告诉你错误是什么。

1
2
3
4
@Override 
public boolean equals(MyClass o) {
...
}
如果有收获,可以请我喝杯咖啡!